์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ 1-2 ๊ณผ์ œํšŒ๊ณ 

@choi2021 ยท October 31, 2022 ยท 16 min read

๐Ÿงจ1-2 ๊ณผ์ œ ํšŒ๊ณ 

์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ ๋‘ ๋ฒˆ์งธ ๊ณผ์ œ๋Š” github API๋ฅผ ์ด์šฉํ•ด ๋‘ ๊ฐ€์ง€ ํŽ˜์ด์ง€ (์ด์Šˆ ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ํŽ˜์ด์ง€)๋ฅผ ๋งŒ๋“œ๋Š” ๊ณผ์ œ์˜€๋‹ค. ์„ธ๋ถ€์ ์ธ ์š”๊ตฌ์‚ฌํ•ญ์—์„œ ๊ฐ€์žฅ ์ค‘์š”ํ–ˆ๋˜ ๋ถ€๋ถ„์€ ๋ฆฌ์ŠคํŠธ์—์„œ Github API ๋ฐ์ดํ„ฐ ์š”์ฒญ, context API๋ฅผ ํ™œ์šฉํ•œ API์—ฐ๋™, ๋‹ค์„ฏ ๋ฒˆ์งธ ์…€์— ๊ด‘๊ณ ์ด๋ฏธ์ง€๋ฅผ ๋„ฃ์–ด์ค„ ๊ฒƒ, ์Šคํฌ๋กค์„ ๋‚ด๋ฆฌ๋ฉด ์ด์Šˆ ๋ชฉ๋ก์— ์ถ”๊ฐ€ ๋กœ๋”ฉ์ด ๋  ์ˆ˜ ์žˆ๊ฒŒ infinity scroll์„ ๊ตฌํ˜„ํ•  ๊ฒƒ์ด์—ˆ๋‹ค. ์œ„ ์š”๊ตฌ์‚ฌํ•ญ์„ ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐํ–ˆ๋Š”์ง€์— ๋Œ€ํ•ด ์ •๋ฆฌํ•˜๊ณ  ์ •๋ฆฌํ•ด ๋ณด๊ณ ์ž ํ•œ๋‹ค.

1. Github Issue API

์ฒซ๋ฒˆ์งธ ๋ฌธ์ œ๋Š” ๊ฐ„๋‹จํ•˜๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ๋ฌธ์„œ๋ฅผ ๋ณด๋‹ˆ list issue API์—์„œ ๊ธฐ๋ณธ์ ์œผ๋กœ open๋œ๊ฑธ ๋ถˆ๋Ÿฌ์˜ค๊ณ  sort query๋ฅผ ์ด์šฉํ•ด์„œ comment๊ฐ€ ๋งŽ์€ ์ˆœ์œผ๋กœ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

image 20221031143703263

๋ฌธ์„œ์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์—๋Ÿฌ๋Š” 404์™€ 422 ๋‘ ๊ฐ€์ง€์ด๊ธฐ ๋•Œ๋ฌธ์— ๋‘ ๊ฐ€์ง€ ์—๋Ÿฌ์— ๋”ฐ๋ผ ์—๋Ÿฌ๋ฉ”์‹œ์ง€๋ฅผ ์ปค์Šคํ…€ํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ด์ „์— ๋งŒ๋“ค์–ด๋‘” httpError class๋ฅผ ์ด์šฉํ•ด ์•Œ๋งž์€ ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•ด ์ค„ ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ๋‹ค.

//issueService.js

import HTTPError from '../network/httpError';

const getIssueList = async page => {
  const response = await fetch(
    `https://api.github.com/repos/angular/angular-cli/issues?sort=comments&per_page=30&page=${page}`,
    {
      method: 'GET',
      headers: {
        Authorization: `token ${process.env.REACT_APP_TOKEN}`,
      },
    }
  );
  if (!response.ok) {
    throw new HTTPError(response.status, response.statusText);
  } else {
    const data = await response.json();
    return data;
  }
};

export default getIssueList;

//httpError.js

export default class HTTPError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.name = 'HTTPError';
    this.statusCode = statusCode;
  }

  get errorMessage() {
    switch (this.statusCode) {
      case 404:
        this.message = 'ํ•ด๋‹น ๋ ˆํฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.';
        break;
      case 422:
        this.message = '์š”์ฒญ์ด ์ž˜๋ชป๋œ endpoint๋กœ ์ „๋‹ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค';
        break;
      default:
        throw new Error('Unknown Error');
    }
    return this.message;
  }
}

2. context API๋ฅผ ํ™œ์šฉํ•œ API ์—ฐ๋™

Context API

context API๋Š” ์ „์—ญ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ, provider๋‚ด๋ถ€์˜ ์ปดํฌ๋„ŒํŠธ๋“ค์—๊ฒŒ ์ „๋‹ฌ์‹œ prop์œผ๋กœ ์ž์‹ ์ปดํฌ๋„ŒํŠธ์— ํ•˜๋‚˜ํ•˜๋‚˜ ์ „๋‹ฌํ•˜๋Š”๊ฒŒ ์•„๋‹ˆ๋ผ, ํ•„์š”ํ•œ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐ”๋กœ ์ƒํƒœ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” db์™€ ๊ฐ™์€ ์—ญํ• ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฒˆ ๊ณผ์ œ์—์„œ context API๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ•œ๋‹ค๋ฉด issue list๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ  ๋ถˆ๋Ÿฌ์˜จ ๋ฐ์ดํ„ฐ๋ฅผ context API์— ๋„ฃ์–ด์ฃผ์–ด, list๋‚ด์šฉ์ด ํ•„์š”ํ•œ ๊ณณ์—์„œ ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ค์—ˆ๋‹ค.

context API ์ž์ฒด์—์„œ api๋ฅผ ์ด์šฉํ•ด ๊ฐ’์„ ๋„ฃ์–ด๋‘˜๊นŒ ์ƒ๊ฐ์„ ํ–ˆ์ง€๋งŒ, ๋‚ด๋ถ€์—์„œ ๊ณ„์† ๊ฐ’์ด ๋ฐ”๋€Œ๋ฉด ๋ฐ›๋Š” ์‹œ์ ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๋ฐ์ดํ„ฐ๊ฐ€ ์ „๋‹ฌ๋  ์ˆ˜๋„ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„ ๋‹จ์ˆœ์ด ๋ฐ์ดํ„ฐ๋งŒ ๋ณด๊ด€ํ•˜๊ณ  ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋Š” ํ•จ์ˆ˜๋ฅผ context๋กœ ๊ฐ™์ด ์ œ๊ณตํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ฝ”๋“œ๋ฅผ ๊ตฌ์„ฑํ–ˆ๋‹ค.

import { useMemo, useState, createContext } from "react"

export const ListContext = createContext()
export const ListContextProvider = ({ children }) => {
  const [issues, setIssues] = useState({})
  const setNextPage = () => setPage(page + 1)
  const value = useMemo(
    () => ({ issues, page, setNextPage, setIssues }),
    [issues, page]
  )
  return <ListContext.Provider value={value}>{children}</ListContext.Provider>
}

Custom Hook: useFetch

๋‘๊ฐ€์ง€ ํŽ˜์ด์ง€ ์ค‘ ์–ด๋””๋ฅผ ๋จผ์ € ์ ‘์†ํ•ด๋„, api๋กœ ๋ฐ์ดํ„ฐ๋ฅผ contextAPI์— ์ €์žฅํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋™์ผํ•œ ๋กœ์ง์„ ๋‘ ํŽ˜์ด์ง€ ๋ชจ๋‘ ๊ฐ€์ง€๊ณ  ์žˆ์–ด์•ผ ํ–ˆ๋‹ค. ๋กœ์ง์˜ ์žฌ์‚ฌ์šฉ์„ ์œ„ํ•ด useFetch๋ผ๋Š” custon Hook์„ ๋งŒ๋“ค์–ด์„œ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ๊ณ , ๋‘ ํŽ˜์ด์ง€ ์ค‘ ์–ด๋””๋ฅผ ์ ‘์†ํ•ด๋„ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์„ฑ๋Šฅ์ด ๋” ์ข‹๊ฒŒ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค.

useEffect ๋‚ด๋ถ€์—์„œ๋Š” async await์œผ๋กœ ํ•จ์ˆ˜๋ฅผ ๊ฐ์‹ธ๋ฉด promise๊ฐ€ ๋ฐ˜ํ™˜๋˜๊ธฐ ๋•Œ๋ฌธ์— ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์–ด, getList๋ฅผ ํ•จ์ˆ˜๋ฅผ ๋”ฐ๋กœ ๋งŒ๋“ค์–ด์„œ useEffect๋กœ ์‹คํ–‰ํ•ด ์ฃผ๋Š” ๋กœ์ง์„ ์‚ฌ์šฉํ–ˆ๋‹ค.

const useFetch = () => {
  const { issues, setIssues, page } = useContext(ListContext)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState("")
  const getList = async () => {
    setIsLoading(true)
    try {
      const data = await getIssueList(page)
      if (data.length === 0) {
        setLastPage(true)
      }
      setIssues(prev => {
        const updated = { ...prev }
        data.forEach(issue => {
          updated[issue.id] = issue
        })
        return updated
      })
    } catch (error) {
      setError(error.errorMessage)
    }
    setIsLoading(false)
  }
  useEffect(() => {
    getList()
  }, [page])

  return [isLoading, error, issues, lastPage]
}

issues ์ „์—ญ์ƒํƒœ ์ž๋ฃŒ๊ตฌ์กฐ

context ๋‚ด๋ถ€ ์ƒํƒœ๋ฅผ ์ฒ˜์Œ์—๋Š” ๋ฐฐ์—ด์„ ์ด์šฉํ•ด api ๋ฐ์ดํ„ฐ๋“ค์„ ๋‹ด์•„๋‘๋ ค๊ณ  ํ–ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ƒ์„ธํŽ˜์ด์ง€์— ๊ฐ”๋‹ค๊ฐ€ ๋Œ์•„์™”์„ ๋•Œ, api๊ฐ€ ํ˜ธ์ถœ๋˜๋ฉด์„œ ๊ฐ™์€ ๋ฐ์ดํ„ฐ๊ฐ€ ๋‘๊ฐœ์”ฉ ๋“ค์–ด๊ฐ€๋Š” ์˜ค๋ฅ˜๊ฐ€ ์ƒ๊ฒผ๋‹ค. ์ด๋Ÿฌํ•œ ์˜ค๋ฅ˜๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ Set์„ ์ด์šฉํ•ด ์ค‘๋ณต๋œ ๋ฐ์ดํ„ฐ๋Š” ์ œ๊ฑฐํ•˜๋ ค ํ–ˆ์ง€๋งŒ, ์—ฌ์ „ํžˆ ๋‚จ์•„์žˆ์—ˆ๋‹ค. ์ค‘๋ณต์ œ๊ฑฐ๊ฐ€ ๋˜์ง€ ์•Š์•˜๋˜ ์ด์œ ๋Š” ๋ฐ์ดํ„ฐ object๋“ค์ด ๊ฐ™์€ ์ž๋ฃŒ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์ง€๋งŒ ๋‹ค๋ฅธ ์ฐธ์กฐ๊ฐ’์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด ๋‹ค๋ฅธ ๊ฐ’์œผ๋กœ ์ฒ˜๋ฆฌ๊ฐ€ ๋œ๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ๋‹ค.

์ด๋Ÿฌํ•œ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ์ฒ˜์Œ์—๋Š” ๋ฐ›์€ ๋ฐฐ์—ด๊ณผ ๊ธฐ์กด ๋ฐฐ์—ด์„ ๋น„๊ตํ•˜๋Š” ๋กœ์ง์„ ์งœ๋ ค๊ณ  ํ–ˆ์ง€๋งŒ, O(n^2)์˜ ์‹œ๊ฐ„๋ณต์žก๋„๋ฅผ ๊ฐ€์ง€๊ธฐ ๋•Œ๋ฌธ์— ์ž๋ฃŒ์–‘์ด ๋งŽ์•„์งˆ์ˆ˜๋ก ์„ฑ๋Šฅ์ด ์•ˆ ์ข‹์•„์งˆ ๊ฒƒ์ด๋ผ๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค.

์ด์ ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ์ž๋ฃŒ๊ตฌ์กฐ๋ฅผ Object๋กœ ๋ฐ”๊พธ์—ˆ๋‹ค. object์˜ key๋ฅผ data์˜ id๋กœ, value๋ฅผ ๋ฐ์ดํ„ฐ ์ž์ฒด๋กœ ํ•œ ์˜ค๋ธŒ์ ํŠธ๋ฅผ ๋งŒ๋“ค๋ฉด, ์ค‘๋ณต์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ณ  ์ดํ›„์— ๋ฐฐ์—ด๋กœ ๋ฐ”๊พธ์–ด mappingํ•  ๋•Œ ์ •๋ ฌ๋งŒ ํ•ด์ฃผ๋ฉด ๋˜๊ธฐ ๋•Œ๋ฌธ์— O(nlogn)์œผ๋กœ ๋ณด๋‹ค ๋‚˜์€ ์„ฑ๋Šฅ์„ ๊ฐ–๊ฒŒ ๋  ๊ฒƒ์ด๋ผ ์˜ˆ์ƒํ–ˆ๋‹ค.

const IssueList = () => {
  const { setNextPage } = useContext(ListContext)
  const [isLoading, error, issues, lastPage] = useFetch()

  return (
    <>
      <S.List>
        {Object.values(issues)
          .sort((a, b) => b.comments - a.comments)
          .map((issue, idx) => {
            return <IssueItem key={issue.id} {...issue} />
          })}
        {isLoading && <Loader />}
      </S.List>
    </>
  )
}

export default IssueList

์ž๋ฃŒ๊ตฌ์กฐ๋ฅผ object๋กœ ๋ฐ”๊พผ ๋•๋ถ„์— detailํŽ˜์ด์ง€์—์„œ ๋ณด์—ฌ์ค„ ๋•Œ๋„ ๋‹ค๋ฅธ api ํ˜ธ์ถœ์—†์ด useParam์œผ๋กœ ๋ฐ›์•„์˜จ id๊ฐ’์œผ๋กœ issues์— ์ ‘๊ทผํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

import React, { memo } from "react"
import { useNavigate, useParams } from "react-router"
import S from "./styles"
import formatDate from "../../utils/formatDate"

const IssueItem = ({ id, number, title, user, created_at, comments }) => {
  const navigate = useNavigate()
  const params = useParams()
  const date = formatDate(created_at)
  const handleClick = () => {
    if (!params.id) {
      navigate(`/detail/${id}`)
    }
  }
  return (
    <S.List onClick={handleClick} params={!!params.id}>
      <S.LeftBox>
        <header>
          <span>{`#${number}`}</span>
          <S.Title>{title}</S.Title>
        </header>
        <div>
          <span>{`์ž‘์„ฑ์ž: ${user && user.login}`}</span>
          <span>{date}</span>
        </div>
      </S.LeftBox>
      <S.RightBox>
        ์ฝ”๋ฉ˜ํŠธ:
        {comments}
      </S.RightBox>
    </S.List>
  )
}

export default memo(IssueItem)

3. ๋‹ค์„ฏ๋ฒˆ์งธ ์…€์— ๊ด‘๊ณ  ๋ณด์—ฌ์ฃผ๊ธฐ

list์˜ ํŠน์ •๋ถ€๋ถ„์— ์ถ”๊ฐ€๋œ ๊ฒƒ์„ ๋ณด์—ฌ์ค€ ๊ฒƒ์„ ํ•ด๋ณธ ์ ์ด ์—†์–ด์„œ ๊ณ ๋ฏผํ•˜๋‹ค, mapping์„ ํ•  ๋•Œ index๊ฐ€ 4๊ฐ€ ๋˜์—ˆ์„ ๋•Œ issueItem ์ปดํฌ๋„ŒํŠธ์™€ ํ•จ๊ป˜ adBox ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค. ํ•˜์ง€๋งŒ key๊ฐ€ ๊ณ„์†ํ•ด์„œ ์ค‘๋ณต๋œ๋‹ค๋Š” ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

import React, { useContext } from "react"
import S from "./styles"
import IssueItem from "../issueItem/IssueItem"
import AdBox from "../adBox/AdBox"
import useFetch from "../../hooks/useFetch"
import useObservation from "../../hooks/useObservation"
import { ListContext } from "../../context/ListContext"
import Loader from "../loader/Loader"

const IssueList = () => {
  const { setNextPage } = useContext(ListContext)
  const [isLoading, error, issues, lastPage] = useFetch()

  return (
    <>
      <S.List>
        {Object.values(issues)
          .sort((a, b) => b.comments - a.comments)
          .map((issue, idx) => {
            if (idx === 4) {
              return (
                <>
                  <AdBox />
                  <IssueItem key={issue.id} {...issue} />
                </>
              )
            }
            return <IssueItem key={issue.id} {...issue} />
          })}
        {isLoading && <Loader />}
      </S.List>
    </>
  )
}

export default IssueList


์–ด๋””์„œ ๊ณ„์†ํ•ด์„œ ์—๋Ÿฌ๊ฐ€ ๋‚˜์˜ค๋Š”์ง€ ์ฐพ๋Š” ์ค‘์— issueItem์— key๊ฐ’์„ ์ฃผ์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒ๋˜์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๊ฒŒ๋˜์—ˆ๋‹ค. ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ fragment๊ฐ€ ์•„๋‹ˆ๋ผ div๋กœ ๊ฐ์‹ธ์ฃผ๊ณ  div์— key๊ฐ’์„ ์ „๋‹ฌํ•ด์ค˜ ์—๋Ÿฌ๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.
import React, { useContext } from "react"
import S from "./styles"
import IssueItem from "../issueItem/IssueItem"
import AdBox from "../adBox/AdBox"
import useFetch from "../../hooks/useFetch"
import useObservation from "../../hooks/useObservation"
import { ListContext } from "../../context/ListContext"
import Loader from "../loader/Loader"

const IssueList = () => {
  const { setNextPage } = useContext(ListContext)
  const [isLoading, error, issues, lastPage] = useFetch()

  return (
    <>
      <S.List>
        {Object.values(issues)
          .sort((a, b) => b.comments - a.comments)
          .map((issue, idx) => {
            if (idx === 4) {
              return (
                <div key={issue.id}>
                  <AdBox />
                  <IssueItem {...issue} />
                </div>
              )
            }
            return <IssueItem key={issue.id} {...issue} />
          })}
        {isLoading && <Loader />}
      </S.List>
      {!lastPage ? (
        <S.Target ref={targetRef} />
      ) : (
        <S.Banner>๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค๐ŸŽˆ</S.Banner>
      )}
    </>
  )
}

export default IssueList

(๋‚˜์ค‘์— ์•ˆ ์‚ฌ์‹ค์ด์ง€๋งŒ react.fragment์—๋„ key๊ฐ’์„ ์ค„ ์ˆ˜ ์žˆ๋‹ค๊ณ  ํ•œ๋‹ค.)

4. Infinite Scroll

์ด๋ฒˆ ๊ณผ์ œ์—์„œ์˜ ๊ฐ€์žฅ ํฐ ํ•ต์‹ฌ ์กฐ๊ฑด์ด์—ˆ๋‹ค. ํ…Œ์˜ค๋‹˜์˜ ์˜คํ”ˆ์ฑ„ํŒ…๋ฐฉ์—์„œ๋„ ๊ฐ„๊ฐ„ํžˆ ์˜ฌ๋ผ์˜ค๋˜ infinite scroll์— ๋Œ€ํ•œ ์งˆ๋ฌธ๋“ค์„ ๋ณด๋ฉด์„œ ์ €๊ฒŒ ์™œ ํ•„์š”ํ•˜์ง€๋ผ๋Š” ์ƒ๊ฐ์„ ํ–ˆ์—ˆ๋Š”๋ฐ ์ด๋ฒˆ์— ์ง์ ‘ ๊ตฌํ˜„ํ•ด๋ณด๋ฉด์„œ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์ด๋ผ๋Š” ์ ์„ ๋งŽ์ด ๋А๊ผˆ๋‹ค. ํ•œ๋ฒˆ๋„ ๊ตฌํ˜„ํ•ด๋ณธ ์ ์ด ์—†๋˜ ๊ธฐ๋Šฅ์ด๊ธฐ ๋•Œ๋ฌธ์— ๊ด€๋ จ ๊ธ€์„ ๋งŽ์ด ์ฐพ์•„๋ณด๊ณ  ๊ฐ€์žฅ ์ž˜ ์ •๋ฆฌ๋˜์–ด ์žˆ๋Š” ์นด์นด์˜ค ์—”ํ„ฐํ”„๋ผ์ด์ฆˆ์˜ ๊ธ€์„ ์ฐธ๊ณ ํ•ด ๋งŒ๋“ค์–ด๋ณด์•˜๋‹ค.

๋งŒ๋“œ๋Š” ๋ฐฉ์‹์„ scroll event๋ฅผ ์ด์šฉํ•˜๋Š” ๋ฐฉ์‹๊ณผ Intersection Observer API๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋‘ ๊ฐ€์ง€ ๋ฐฉ์‹์ด ์„ค๋ช…๋˜์–ด ์žˆ์—ˆ๋Š”๋ฐ, ์ด์ „์— Intersection Observer API๋ฅผ ์‚ฌ์šฉํ•ด๋ณธ ๊ฒฝํ—˜์ด ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ณง๋ฐ”๋กœ Intersection Observer API๋ฅผ ์ด์šฉํ•ด์„œ ๊ตฌํ˜„ํ•ด ๋ณด์•˜๋‹ค.

๊ธฐ๋ณธ์ ์ธ ๋กœ์ง์€ observer๊ฐ€ ๊ด€์ฐฐํ•ด์•ผ ํ•  target์„ ๋งŒ๋“ค๊ณ , observer๊ฐ€ ๊ฐ์ง€ํ•  ์˜์—ญ์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ๋‹ด์€ option๊ณผ ๊ฐ์ง€๋˜์—ˆ์„ ๋•Œ API ํ˜ธ์ถœ์„ ํ•ด์ค„ callback์„ ์ „๋‹ฌํ•ด ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์ด๋‹ค. callbackํ•จ์ˆ˜๋Š” useFetch hook์— ์—ฐ๊ฒฐํ•ด๋‘์—ˆ๋˜ page๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๋Š”๋ฐ, page ์ƒํƒœ๋ฅผ ์•ž์„  useFetch์˜ useEffect hook์˜ dependency๋กœ ์ „๋‹ฌํ•ด๋‘์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ํŽ˜์ด์ง€ ๋ณ€ํ™”์— ๋”ฐ๋ผ api ํ˜ธ์ถœ์ด ์ž๋™์œผ๋กœ ์—ฐ๊ฒฐ๋œ๋‹ค.

Custom Hook: useObservation

Observation์„ ํ•˜๋Š” ๋กœ์ง์„ ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ๋ฅผ ํ•  ์ˆ˜ ์žˆ๊ฒŒ useObservation์ด๋ผ๋Š” Hook์œผ๋กœ ๋กœ์ง๋“ค์„ ์ •๋ฆฌํ–ˆ๋‹ค. Hook์€ ๊ด€์ฐฐํ•  ref๋ฅผ ๋ฐ˜ํ™˜ํ•ด ref๋ฅผ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ํƒ€๊ฒŸ์œผ๋กœ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.

import { useCallback, useEffect, useRef } from "react"

const option = {
  root: null,
  rootMargin: "0px",
  threshold: 1,
}

const useObservation = onIntersect => {
  const ref = useRef(null)
  const callback = useCallback(
    (entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) onIntersect(entry, observer)
      })
    },
    [onIntersect]
  )

  useEffect(() => {
    if (!ref.current) {
      return
    }
    const observer = new IntersectionObserver(callback, option)
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [ref.current, callback])
  return ref
}

export default useObservation

์–ด๋ ค์› ๋˜ ์ ์€ ์ดˆ๊ธฐ ๋ Œ๋”๋ง ์‹œ์— ์ „๋‹ฌํ•ด์ฃผ์—ˆ๋˜ callback์ด ์‹คํ–‰๋˜์–ด์„œ ๊ณ„์†ํ•ด์„œ page 2์ธ ์ƒํƒœ๋กœ ์‹œ์ž‘๋˜๋Š” ์ ์ด์—ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ง‰๊ธฐ ์œ„ํ•ด์„œ useFetch์˜ isLoading์ƒํƒœ๋ฅผ ์ด์šฉํ•ด์„œ ๋กœ๋”ฉ์ด ์•„๋‹ ๋•Œ๋งŒ useObservation์— ์ „๋‹ฌํ•ด์ค€ callback ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋˜๊ฒŒ ํ–ˆ๋‹ค.

import React, { useContext } from "react"
import S from "./styles"
import IssueItem from "../issueItem/IssueItem"
import AdBox from "../adBox/AdBox"
import useFetch from "../../hooks/useFetch"
import useObservation from "../../hooks/useObservation"
import { ListContext } from "../../context/ListContext"
import Loader from "../loader/Loader"

const IssueList = () => {
  const { setNextPage } = useContext(ListContext)
  const [isLoading, error, issues] = useFetch()
  const onObserve = (entry, observer) => {
    observer.unobserve(entry.target)
    if (!isLoading) {
      setNextPage()
    }
  }

  const targetRef = useObservation(onObserve)

  return (
    <>
      <S.List>
        {Object.values(issues)
          .sort((a, b) => b.comments - a.comments)
          .map((issue, idx) => {
            if (idx === 4) {
              return (
                <div key={issue.id}>
                  <AdBox />
                  <IssueItem {...issue} />
                </div>
              )
            }
            return <IssueItem key={issue.id} {...issue} />
          })}
        {isLoading && <Loader />}
      </S.List>
      <S.Target ref={targetRef} />
    </>
  )
}

export default IssueList

๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€ ๋ฌดํ•œ API ํ˜ธ์ถœ

๊ตฌํ˜„์ด ๋‹ค ๋๋‚œ ์ค„ ์•Œ์•˜์ง€๋งŒ ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์—์„œ ๊ณ„์†ํ•ด์„œ API๊ฐ€ ํ˜ธ์ถœ๋˜๋Š” ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

์—๋Ÿฌ ํ•ด๊ฒฐ์„ ์œ„ํ•ด์„œ ํŒ€์›๋ถ„๋“ค์˜ ๋„์›€์„ ๋ฐ›์•„ useFetch์— lastPage๋ผ๋Š” ์ƒํƒœ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๊ณ , ๋” ์ด์ƒ ๋ถˆ๋Ÿฌ์˜ฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฐฐ์—ด๋กœ ๋ฐ›์•„์˜ค๋Š” ์ ์„ ์ด์šฉํ•ด data.length๊ฐ€ 0์ผ ๋•Œ lastPage๋ฅผ True๋กœ ๋ฐ”๊ฟ” ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

//useFetch.jsx

const useFetch = () => {
  const { issues, setIssues, page } = useContext(ListContext);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState('');
  const [lastPage, setLastPage] = useState(false);
  const getList = async () => {
    setIsLoading(true);
    try {
      const data = await getIssueList(page);
      if (data.length === 0) {
        setLastPage(true);
      }
    ...
  };
  useEffect(() => {
    getList();
  }, [page]);

  return [isLoading, error, issues, lastPage];
};

export default useFetch;


//issueList.jsx

import React, { useContext } from 'react';
import S from './styles';
import IssueItem from '../issueItem/IssueItem';
import AdBox from '../adBox/AdBox';
import useFetch from '../../hooks/useFetch';
import useObservation from '../../hooks/useObservation';
import { ListContext } from '../../context/ListContext';
import Loader from '../loader/Loader';

const IssueList = () => {
  const { setNextPage } = useContext(ListContext);
  const [isLoading, error, issues, lastPage] = useFetch();
  const onObserve = (entry, observer) => {
    observer.unobserve(entry.target);
    if (!isLoading && !lastPage) {
      setNextPage();
    }
  };

  const targetRef = useObservation(onObserve);

  return (
    <>
      <S.List>
		...
      </S.List>
      {!lastPage ? (
        <S.Target ref={targetRef} />
      ) : (
        <S.Banner>๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค๐ŸŽˆ</S.Banner>
      )}
    </>
  );
};

๋งˆ์น˜๋ฉฐ

ํ˜ผ์ž ๊ณต๋ถ€ํ•  ๋•Œ๋Š” ๊ทธ๋ƒฅ ์—๋Ÿฌํ•ด๊ฒฐ์„ ์œ„ํ•ด์„œ ํ•ด๊ฒฐ๋ฐฉ๋ฒ•์„ ์ฐพ๊ณ  ๊ธฐ๋กํ•˜๋Š” ๊ฒŒ ๋‹ค์˜€๋Š”๋ฐ, ํŒ€์›๋ถ„๋“ค์ด ๋ฌผ์–ด๋ด ์ฃผ์‹œ๊ณ  ๋„์™€์ฃผ์‹œ๋Š” ๊ณผ์ •์ด ๋‚˜์—๊ฒŒ ๋„ˆ๋ฌด ๋„ˆ๋ฌด ์†Œ์ค‘ํ•œ ๊ฒฝํ—˜์ด์—ˆ๋‹ค. ๋‚ด๊ฐ€ ์™œ ์ด๋ ‡๊ฒŒ ์ฝ”๋“œ๋ฅผ ์งฐ๋Š”์ง€ ๋ฌผ์–ด ๋ด์ฃผ๋Š” ์‚ฌ๋žŒ์ด ์žˆ๋‹ค๋Š” ๊ฒŒ,ํ”ผ๋“œ๋ฐฑ๊ณผ ์งˆ๋ฌธ์„ ํ•ด์ฃผ์‹œ๋Š” ๋ถ„์ด ์žˆ๋‹ค๋Š” ๊ฒŒ ๋‚ด๊ฐ€ ์„ฑ์žฅํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐํšŒ๋ผ๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค. ์•ž์œผ๋กœ๋Š” ๋” ๊ณ ๋ฏผํ•˜๊ณ  ์™œ์™€ ์–ด๋–ป๊ฒŒ๋ฅผ ์ž˜ ์„ค๋ช…ํ•˜๋Š” ์ฝ”๋“œ๋“ค์„ ๋‹ด์•„๋‚ด์„œ ๋” ์ž˜ ์ค€๋น„ํ•ด ๋‚˜๊ฐˆ ์ˆ˜ ์žˆ๋Š” ์‹œ๊ฐ„์œผ๋กœ ์‚ผ์•„ ๊ฐ€์•ผ๊ฒ ๋‹ค.

@choi2021
๋งค์ผ์˜ ์‹œํ–‰์ฐฉ์˜ค๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฐœ๋ฐœ์ผ์ง€์ž…๋‹ˆ๋‹ค.